@link-assistant/web-capture 1.7.23 → 1.7.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/package.json +1 -1
- package/src/extract-images.js +4 -1
- package/src/gdocs-preprocess.js +171 -0
- package/tests/fixtures/bold-coalesce-and-leakage.html +27 -0
- package/tests/unit/bold-coalesce-and-leakage.test.js +54 -0
- package/tests/unit/extract-images.test.js +20 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @link-assistant/web-capture
|
|
2
2
|
|
|
3
|
+
## 1.7.25
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 024cb45: Fix `extractAndSaveImages` / `extractBase64ToBuffers` silently dropping base64 images when the markdown image is followed by a title attribute (e.g. ``). The base64 payload regex used `[^)]+`, which greedily included the trailing ` ""` and made `Buffer.from()` produce a decoded buffer that no longer matched the original image. The pattern now restricts the payload to base64 alphabet characters and matches the optional trailing markdown title separately so the title cannot leak into the payload.
|
|
8
|
+
|
|
9
|
+
## 1.7.24
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 52ccb91: Fix structurally broken bold markup in `--capture api` output. Adjacent `<span style="font-weight:700">` siblings that the Google Docs export emits for headings, list items, and notes used to be wrapped in independent `<strong>...</strong>` pairs, producing split runs (`**13.1** **First subsection**`), stray `****` between adjacent bold blocks, and bold ranges that leaked across `<br>`/`<img>` boundaries (`**Caption A:Caption B:**`). The Google Docs preprocessor now closes `<strong>` runs at `<br>`/`<img>` boundaries, splits the wrapping `<p>` into separate paragraphs at those boundaries, drops empty `<strong></strong>` pairs, and coalesces adjacent bold siblings into a single run. Renderers can now interpret the output as valid CommonMark again.
|
|
14
|
+
|
|
3
15
|
## 1.7.23
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/package.json
CHANGED
package/src/extract-images.js
CHANGED
|
@@ -2,8 +2,11 @@ import { createHash } from 'crypto';
|
|
|
2
2
|
import fs from 'fs';
|
|
3
3
|
import path from 'path';
|
|
4
4
|
|
|
5
|
+
// Capture groups: alt text, mime subtype, base64 payload. A trailing
|
|
6
|
+
// markdown title attribute (e.g. ``) is matched but discarded so
|
|
7
|
+
// the empty title cannot leak into the base64 payload.
|
|
5
8
|
const BASE64_IMAGE_REGEX =
|
|
6
|
-
/!\[([^\]]*)\]\(data:image\/(png|jpeg|jpg|gif|webp|svg\+xml);base64,([
|
|
9
|
+
/!\[([^\]]*)\]\(data:image\/(png|jpeg|jpg|gif|webp|svg\+xml);base64,([A-Za-z0-9+/=]+)(?:\s+"[^"]*")?\)/gi;
|
|
7
10
|
|
|
8
11
|
/**
|
|
9
12
|
* @param {string} markdown - Markdown content with data URI images
|
package/src/gdocs-preprocess.js
CHANGED
|
@@ -62,6 +62,11 @@ export function preprocessGoogleDocsExportHtml(html) {
|
|
|
62
62
|
hoisted += 1;
|
|
63
63
|
});
|
|
64
64
|
|
|
65
|
+
splitStrongAtBlockBoundaries($);
|
|
66
|
+
splitParagraphsAtBoldBoundaries($);
|
|
67
|
+
removeEmptyStrong($);
|
|
68
|
+
coalesceAdjacentStrong($);
|
|
69
|
+
|
|
65
70
|
groupGoogleDocsBlockquotes($, classStyles);
|
|
66
71
|
nestGoogleDocsLists($, classStyles);
|
|
67
72
|
normalizeGoogleDocsTables($);
|
|
@@ -144,6 +149,172 @@ export function normalizeGoogleDocsExportMarkdown(markdown) {
|
|
|
144
149
|
.replace(/\n{3,}/g, '\n\n');
|
|
145
150
|
}
|
|
146
151
|
|
|
152
|
+
// Issue #120: a `<strong>` whose closing tag would land in a different block
|
|
153
|
+
// boundary leaks bolding across paragraphs and images. Split such a strong
|
|
154
|
+
// into multiple bold runs that each stop at the boundary.
|
|
155
|
+
function splitStrongAtBlockBoundaries($) {
|
|
156
|
+
let changed = true;
|
|
157
|
+
while (changed) {
|
|
158
|
+
changed = false;
|
|
159
|
+
const offenders = $('strong').filter(function () {
|
|
160
|
+
return $(this).find('br, img, p, div, li').length > 0;
|
|
161
|
+
});
|
|
162
|
+
if (!offenders.length) {
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
offenders.each(function () {
|
|
166
|
+
const $strong = $(this);
|
|
167
|
+
const segments = [];
|
|
168
|
+
let current = [];
|
|
169
|
+
const flush = () => {
|
|
170
|
+
if (current.length) {
|
|
171
|
+
segments.push({ type: 'inline', nodes: current });
|
|
172
|
+
current = [];
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
$strong.contents().each(function () {
|
|
176
|
+
if (this.type === 'tag' && this.tagName === 'br') {
|
|
177
|
+
flush();
|
|
178
|
+
segments.push({ type: 'br' });
|
|
179
|
+
} else if (this.type === 'tag' && this.tagName === 'img') {
|
|
180
|
+
flush();
|
|
181
|
+
segments.push({ type: 'img', node: this });
|
|
182
|
+
} else if (
|
|
183
|
+
this.type === 'tag' &&
|
|
184
|
+
['p', 'div', 'li'].includes(this.tagName)
|
|
185
|
+
) {
|
|
186
|
+
flush();
|
|
187
|
+
segments.push({ type: 'block', node: this });
|
|
188
|
+
} else {
|
|
189
|
+
current.push(this);
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
flush();
|
|
193
|
+
|
|
194
|
+
const pieces = segments.map((segment) => {
|
|
195
|
+
if (segment.type === 'br') {
|
|
196
|
+
return '<br>';
|
|
197
|
+
}
|
|
198
|
+
if (segment.type === 'img') {
|
|
199
|
+
return $.html(segment.node);
|
|
200
|
+
}
|
|
201
|
+
if (segment.type === 'block') {
|
|
202
|
+
const inner = $(segment.node).html() || '';
|
|
203
|
+
const tag = segment.node.tagName;
|
|
204
|
+
const innerWrapped = inner.trim() ? `<strong>${inner}</strong>` : '';
|
|
205
|
+
return `<${tag}>${innerWrapped}</${tag}>`;
|
|
206
|
+
}
|
|
207
|
+
const inner = segment.nodes.map((node) => $.html(node)).join('');
|
|
208
|
+
if (!inner.trim()) {
|
|
209
|
+
return inner;
|
|
210
|
+
}
|
|
211
|
+
return `<strong>${inner}</strong>`;
|
|
212
|
+
});
|
|
213
|
+
$strong.replaceWith(pieces.join(''));
|
|
214
|
+
changed = true;
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Issue #120: when a `<p>` contains a `<br>` adjacent to a `<strong>` or an
|
|
220
|
+
// `<img>`, split it into multiple paragraphs so each bold run and image
|
|
221
|
+
// becomes its own block. Without this, the markdown converter renders
|
|
222
|
+
// `**Caption** \n` (inline) instead of separate blocks.
|
|
223
|
+
function splitParagraphsAtBoldBoundaries($) {
|
|
224
|
+
$('p').each(function () {
|
|
225
|
+
const $p = $(this);
|
|
226
|
+
const contents = $p.contents().toArray();
|
|
227
|
+
if (!contents.some((n) => n.type === 'tag' && n.tagName === 'br')) {
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
const hasStrongOrImg = contents.some(
|
|
231
|
+
(n) => n.type === 'tag' && (n.tagName === 'strong' || n.tagName === 'img')
|
|
232
|
+
);
|
|
233
|
+
if (!hasStrongOrImg) {
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const segments = [];
|
|
237
|
+
let current = [];
|
|
238
|
+
const flush = () => {
|
|
239
|
+
const html = current
|
|
240
|
+
.map((node) => $.html(node))
|
|
241
|
+
.join('')
|
|
242
|
+
.trim();
|
|
243
|
+
if (html) {
|
|
244
|
+
segments.push(html);
|
|
245
|
+
}
|
|
246
|
+
current = [];
|
|
247
|
+
};
|
|
248
|
+
for (const node of contents) {
|
|
249
|
+
if (node.type === 'tag' && node.tagName === 'br') {
|
|
250
|
+
flush();
|
|
251
|
+
} else if (node.type === 'tag' && node.tagName === 'img') {
|
|
252
|
+
flush();
|
|
253
|
+
segments.push($.html(node));
|
|
254
|
+
} else {
|
|
255
|
+
current.push(node);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
flush();
|
|
259
|
+
if (segments.length <= 1) {
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
const replacement = segments.map((html) => `<p>${html}</p>`).join('');
|
|
263
|
+
$p.replaceWith(replacement);
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Issue #120: drop empty `<strong></strong>` so they cannot pair with a
|
|
268
|
+
// neighbour and emit `****`.
|
|
269
|
+
function removeEmptyStrong($) {
|
|
270
|
+
$('strong').each(function () {
|
|
271
|
+
const $strong = $(this);
|
|
272
|
+
if (!$strong.html() || !$strong.text().trim()) {
|
|
273
|
+
if (!$strong.find('img').length) {
|
|
274
|
+
$strong.remove();
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Issue #120: merge adjacent `<strong>` siblings (optionally separated by
|
|
281
|
+
// whitespace text nodes) into a single `<strong>` so the converter emits
|
|
282
|
+
// `**a b**` instead of `**a** **b**`.
|
|
283
|
+
function coalesceAdjacentStrong($) {
|
|
284
|
+
let changed = true;
|
|
285
|
+
while (changed) {
|
|
286
|
+
changed = false;
|
|
287
|
+
$('strong').each(function () {
|
|
288
|
+
const $strong = $(this);
|
|
289
|
+
if (!$strong.parent().length) {
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
let sibling = this.next;
|
|
293
|
+
const between = [];
|
|
294
|
+
while (sibling) {
|
|
295
|
+
if (sibling.type === 'text' && /^\s*$/.test(sibling.data || '')) {
|
|
296
|
+
between.push(sibling);
|
|
297
|
+
sibling = sibling.next;
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
if (sibling.type === 'tag' && sibling.tagName === 'strong') {
|
|
301
|
+
const innerSelf = $strong.html() || '';
|
|
302
|
+
const innerNext = $(sibling).html() || '';
|
|
303
|
+
const joiner = between.map((node) => node.data || '').join('');
|
|
304
|
+
$strong.html(`${innerSelf}${joiner}${innerNext}`);
|
|
305
|
+
for (const node of between) {
|
|
306
|
+
$(node).remove();
|
|
307
|
+
}
|
|
308
|
+
$(sibling).remove();
|
|
309
|
+
changed = true;
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
break;
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
147
318
|
function parseCssClassStyles($) {
|
|
148
319
|
const classStyles = new Map();
|
|
149
320
|
$('style').each(function () {
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html>
|
|
3
|
+
<body>
|
|
4
|
+
<!-- Symptom A: two adjacent bold spans -->
|
|
5
|
+
<p>
|
|
6
|
+
<span style="font-weight: 700">13.1 </span
|
|
7
|
+
><span style="font-weight: 700">First subsection</span>
|
|
8
|
+
</p>
|
|
9
|
+
|
|
10
|
+
<!-- Symptom B: outer bold ends, inner bold begins, no separator -->
|
|
11
|
+
<ol>
|
|
12
|
+
<li><span style="font-weight: 700">Item header.</span></li>
|
|
13
|
+
</ol>
|
|
14
|
+
<p><span style="font-weight: 700">Note:</span> body text.</p>
|
|
15
|
+
|
|
16
|
+
<!-- Symptom C: bold range spans across <br> and <img> -->
|
|
17
|
+
<p>
|
|
18
|
+
<span style="font-weight: 700"
|
|
19
|
+
>Caption A:<img
|
|
20
|
+
alt=""
|
|
21
|
+
src="data:image/png;base64,iVBORw0KGgo=" /><br />Caption B:<img
|
|
22
|
+
alt=""
|
|
23
|
+
src="data:image/png;base64,iVBORw0KGgo="
|
|
24
|
+
/></span>
|
|
25
|
+
</p>
|
|
26
|
+
</body>
|
|
27
|
+
</html>
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
import {
|
|
5
|
+
normalizeGoogleDocsExportMarkdown,
|
|
6
|
+
preprocessGoogleDocsExportHtml,
|
|
7
|
+
} from '../../src/gdocs.js';
|
|
8
|
+
import { convertHtmlToMarkdown } from '../../src/lib.js';
|
|
9
|
+
|
|
10
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
11
|
+
const __dirname = path.dirname(__filename);
|
|
12
|
+
const HTML = fs.readFileSync(
|
|
13
|
+
path.join(__dirname, '..', 'fixtures', 'bold-coalesce-and-leakage.html'),
|
|
14
|
+
'utf8'
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
describe('bold-run coalescing and block boundary handling (issue #120)', () => {
|
|
18
|
+
let md;
|
|
19
|
+
beforeAll(() => {
|
|
20
|
+
const preprocessed = preprocessGoogleDocsExportHtml(HTML);
|
|
21
|
+
md = normalizeGoogleDocsExportMarkdown(
|
|
22
|
+
convertHtmlToMarkdown(preprocessed.html)
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('A: coalesces adjacent bold spans into a single bold run', () => {
|
|
27
|
+
expect(md).toMatch(/\*\*13\.1 First subsection\*\*/);
|
|
28
|
+
expect(md).not.toMatch(/\*\*13\.1\*\*\s+\*\*First subsection\*\*/);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('B: never emits an empty "****" pair between adjacent bold runs', () => {
|
|
32
|
+
expect(md).not.toMatch(/\*{4}/);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('C: closes bold at block-level boundaries (<br>, <img>)', () => {
|
|
36
|
+
// No single bold run may contain an image. CommonMark bold cannot span
|
|
37
|
+
// a blank line (`\n\n`), so the inner content is restricted accordingly.
|
|
38
|
+
const strongRe = /\*\*((?:(?!\*\*|\n\n)[\s\S])+?)\*\*/g;
|
|
39
|
+
const imageRe = /!\[[^\]]*\]\([^)]+\)/;
|
|
40
|
+
for (const match of md.matchAll(strongRe)) {
|
|
41
|
+
expect(imageRe.test(match[1])).toBe(false);
|
|
42
|
+
}
|
|
43
|
+
expect(md).toMatch(/\*\*Caption A:\*\*/);
|
|
44
|
+
expect(md).toMatch(/\*\*Caption B:\*\*/);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('D: every "**" opener has a matching closer (balanced asterisks)', () => {
|
|
48
|
+
const stripped = md
|
|
49
|
+
.replace(/`[^`]*`/g, '')
|
|
50
|
+
.replace(/!\[[^\]]*\]\([^)]*\)/g, '');
|
|
51
|
+
const count = (stripped.match(/\*\*/g) || []).length;
|
|
52
|
+
expect(count % 2).toBe(0);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -130,6 +130,26 @@ describe('extract-images module', () => {
|
|
|
130
130
|
);
|
|
131
131
|
});
|
|
132
132
|
|
|
133
|
+
it('extracts a base64 image even when followed by an empty markdown title', () => {
|
|
134
|
+
// Google Docs HTML exports embed `<img title="" alt="" src="...">`, and
|
|
135
|
+
// the html2md pipeline can render that as ``. The base64
|
|
136
|
+
// extractor must still pull the image out cleanly so the rendered
|
|
137
|
+
// markdown does not carry multi-megabyte data URIs.
|
|
138
|
+
const md = `Hello.\n\n\n\nEnd.\n`;
|
|
139
|
+
const result = extractAndSaveImages(md, tmpDir);
|
|
140
|
+
expect(result.extracted).toBe(1);
|
|
141
|
+
expect(result.markdown).toMatch(/images\/image-[a-f0-9]+\.png/);
|
|
142
|
+
expect(result.markdown).not.toContain('data:image');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('extracts a base64 image with a non-empty markdown title', () => {
|
|
146
|
+
const md = `Hello.\n\n\n\nEnd.\n`;
|
|
147
|
+
const result = extractAndSaveImages(md, tmpDir);
|
|
148
|
+
expect(result.extracted).toBe(1);
|
|
149
|
+
expect(result.markdown).toMatch(/images\/image-[a-f0-9]+\.png/);
|
|
150
|
+
expect(result.markdown).not.toContain('data:image');
|
|
151
|
+
});
|
|
152
|
+
|
|
133
153
|
it('handles SVG data URIs', () => {
|
|
134
154
|
const svgContent =
|
|
135
155
|
'<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"><rect fill="red" width="1" height="1"/></svg>';
|